/**
 * @module amino.encode
 */

import vstruct from 'varstruct'
import { Buffer } from 'safe-buffer'
import is from 'is_js'

import { UVarInt } from './varint'
import typeToTyp3 from '../typeToTyp3'

const VarString = vstruct.VarString(UVarInt)

const sortObject = obj => {
  if (obj === null) return null
  if (typeof obj !== 'object') return obj
  // arrays have typeof "object" in js!
  if (Array.isArray(obj)) { return obj.map(sortObject) }
  const sortedKeys = Object.keys(obj).sort()
  const result = {}
  sortedKeys.forEach(key => {
    result[key] = sortObject(obj[key])
  })
  return result
}

/**
 * encode number
 * @param num
 */
const encodeNumber = (num) => UVarInt.encode(num)

/**
 * encode bool
 * @param b
 */
const encodeBool = (b) => b ? UVarInt.encode(1) : UVarInt.encode(0)

/**
 * encode string
 * @param str
 */
const encodeString = (str) => VarString.encode(str)

/**
 * encode time
 * @param value
 */
const encodeTime = (value) => {
  const millis = new Date(value).getTime()
  const seconds = Math.floor(millis / 1000)
  const nanos = Number(seconds.toString().padEnd(9, '0'))

  const buffer = Buffer.alloc(14)

  buffer[0] = (1 << 3) | 1// field 1, typ3 1
  buffer.writeUInt32LE(seconds, 1)

  buffer[9] = (2 << 3) | 5// field 2, typ3 5
  buffer.writeUInt32LE(nanos, 10)

  return buffer
}

/**
 * @param obj -- {object}
 * @return bytes {Buffer}
 */
const convertObjectToSignBytes = obj => {
  return Buffer.from(JSON.stringify(sortObject(obj)))
}

/**
 * prefixed with bytes length
 * @param {Buffer} bytes
 * @return {Buffer} with bytes length prefixed
 */
const encodeBinaryByteArray = (bytes) => {
  const lenPrefix = bytes.length
  return Buffer.concat([UVarInt.encode(lenPrefix), bytes])
}

// Write field key.
const encodeTypeAndField = (index, field) => {
  const value = index << 3 | typeToTyp3(field)
  return UVarInt.encode(value)
}

const isDefaultValue = (obj) => {
  if (obj === null) return false

  return (is.number(obj) && obj === 0) ||
        (is.string(obj) && obj === '') ||
        (is.array(obj) && obj.length === 0) ||
        (is.boolean(obj) && !obj)
}

export default class Encoder {
  constructor (typePrefixes = {}) {
    this._typePrefixes = typePrefixes
  }

  marshalBinaryLengthPrefixed (obj) {
    if (!is.object(obj)) { throw new TypeError('data must be an object') }

    return this._encodeBinary(obj, -1, true)
  };

  marshalBinaryBare (obj) {
    if (!is.object(obj)) { throw new TypeError('data must be an object') }

    return this._encodeBinary(obj).toString('hex')
  }

  marshalJSON (obj) {
    const msg = {}
    if (this._typePrefixes[obj.__msgType__]) {
      msg.type = obj.__msgType__
      msg.value = obj
      return msg
    }
    return obj
  }

  /**
     * This is the main entrypoint for encoding all types in binary form.
     * @param {*} js data type (not null, not undefined)
     * @param {Number} field index of object
     * @param {Boolean} isByteLenPrefix
     * @return {Buffer} binary of object.
     */
  _encodeBinary (val, fieldNum, isByteLenPrefix) {
    if (val === null || val === undefined) {
      throw new TypeError('unsupported type')
    }

    if (Buffer.isBuffer(val)) {
      if (isByteLenPrefix) {
        return Buffer.concat([UVarInt.encode(val.length), val])
      }
      return val
    }

    if (is.array(val)) {
      return this._encodeArrayBinary(fieldNum, val, isByteLenPrefix)
    }

    if (is.number(val)) {
      return encodeNumber(val)
    }

    if (is.boolean(val)) {
      return encodeBool(val)
    }

    if (is.string(val)) {
      return encodeString(val)
    }

    if (is.object(val)) {
      return this._encodeObjectBinary(val, isByteLenPrefix)
    }
  };

  /**
     * @param {Number} fieldNum object field index
     * @param {Array} arr
     * @param {Boolean} isByteLenPrefix
     * @return {Buffer} bytes of array
     */
  _encodeArrayBinary (fieldNum, arr, isByteLenPrefix) {
    const result = []

    arr.forEach((item) => {
      result.push(encodeTypeAndField(fieldNum, item))

      if (isDefaultValue(item)) {
        result.push(Buffer.from('00', 'hex'))
        return
      }

      result.push(this._encodeBinary(item, fieldNum, true))
    })

    // encode length
    if (isByteLenPrefix) {
      const length = result.reduce((prev, item) => (prev + item.length), 0)
      result.unshift(UVarInt.encode(length))
    }

    return Buffer.concat(result)
  };

  /**
     *
     * @param {Object} obj
     * @return {Buffer} with bytes length prefixed
     */
  _encodeObjectBinary (obj, isByteLenPrefix) {
    const bufferArr = []
    let index = 1
    Object.keys(obj).forEach((key) => {
      if (key.startsWith('__')) return

      if (isDefaultValue(obj[key])) return

      if (is.array(obj[key]) && obj[key].length > 0) {
        bufferArr.push(this._encodeArrayBinary(index, obj[key], false))
      } else {
        bufferArr.push(encodeTypeAndField(index, obj[key]))
        bufferArr.push(this._encodeBinary(obj[key], index, true))
      }
      index++
    })

    let bytes = Buffer.concat(bufferArr)

    // add prefix
    if (this._typePrefixes[obj.__msgType__]) {
      const prefix = Buffer.from(this._typePrefixes[obj.__msgType__])
      bytes = Buffer.concat([prefix, bytes])
    }

    // Write byte-length prefixed.
    if (isByteLenPrefix) {
      const lenBytes = UVarInt.encode(bytes.length)
      bytes = Buffer.concat([lenBytes, bytes])
    }
    return bytes
  };
}
